React SSR基本のキ
担当はkeroxp.iconでお送りします。
ReactのSSR(Server Side Rendering)の基本的な使い方と、実際のところを語ります。
結論
Reactのレンダリングには、いくつかの種類がある
初期ビュー構築をクライアントだけで完結するCSR(Client Side Rendering)
初期ビュー構築をサーバーだけで完結するSSR(Server Side Rendering)
初期ビュー構築をサーバーとクライアントで分担するハイブリッドレンダリング
Reactは用途によってサーバー、クライアント両方で使えるビューライブラリである
クライアントとサーバー両方で同じコンポーネントを使うSSRは、その性質上用途がかなり限定される
サーバーをNode.jsで作るのであればハイブリッドレンダリングがおすすめ
内容
SSRはそもそも、クライアントサイドのビューライブラリであるReactのCSR(Client Side Rendering)の姉妹的な概念です。
Reactが登場した当初、Reactはクライアントサイドの複雑なDOM構築・変更をスッキリ書くため、それまでサーバー側でサーブしていたHTMLに相当するものをJSX(HTML in JS) / Virtual DOMでクライアント側でビューをレンダリングするという使い方が推奨されました。
またクライアントアプリをを一つのルート、一つのビューで構成するパターンをSPA(Single Page Application)と呼ぶようになりました。
しかし、それからしばらくして開発者はReactのSPAがいいことばかりでもないということに気が付き始めました。
最近NetflixがクライアントでのReactの使用を止めたというニュースがありましたが、それは彼ら曰く、ReactファミリーをバンドルしたJavaScriptのロード時間と、VDOMのファーストレンダリングの速度がどうしても改善できず、目標とするTTI(Time to interactive)を達成できなかったからだそうです。 結果彼らはクライアントサイドでのReactの使用を止め、サーバーサイドでSSRとしてだけ使うようになったとか。今どうなってるのかは知りませんが。
また、後述するCSRには、ビューを構成する数多くのデータもクライアントサイドから取得しないと行けないとという大きなデメリットもあります。
iOSやAndroidなどのネイティブアプリであればまずデータを取得しに行き、データが用意できるまでローディング画面を表示しておくというのは当たり前のプラクティスです。
しかし、Webにおいてはそうでもない、というかそうでもなかったといえます。
ブラウザは、サイトにアクセスし、そこからHTMLをダウンロードして表示していますが、Pure CSRの場合はそこから無数のajax通信でビューを構築するデータを取得することに奔走します。(その間、ストアだかアクションだかの複雑な動きがバックグラウンドで走りまくっています)
更に、ReactファミリーはクライアントJSの中でも相当に重い部類に入り、貧弱な回線ではそのダウンロードにも時間がかかります。
ブラウザにはロードインジケーターがぐるぐると回り続け、ユーザはイライラしはじめます。
どうしてこうなってしまったのでしょうか?
DOMを操作するのが重く構造化されていないというモチベーションで作られたはずのReactが、それまで嫌々使っていたPHPやRailsよりもユーザ体験を阻害するというのは本末転倒です。
ユーザーはReactではなくコンテンツを見にWebサイトに来ているわけですから、どんな環境であろうと一刻も早くコンテンツを見たいわけです。
CSR
まずCSRのおさらいです。CSRはサーバーがもととなるHTMLをサーブし、クライアント側のReactでビューを構築します。
実際にはコンテンツはサーブせず、クライアントのルートコンポーネントの入れ物になるDOMだけだけ入れておくのがトレンドとされました。
code:index.tsx
res.send(`
<html>
<head>
<title>${"Client Side Rendering"}</title>
</head>
<body>
<div id="root"></div>
<script src="/dist/render.js"></script>
</body>
</html>
`);
code:client/render.tsx
const Index = ({title}) => (
<div>
<h1>Hello {title}!</h1>
<div>
)
render(
<Index title={"Client Side Rendering"} />, document.getElementById("root")
);
SSR
SSRでは、サーバー(Node.js / Express)からクライアントサイドにReactコンポーネントをHTMLとしてサーブする必要があります。
まずはコードから行きましょう
サーバーのコードは以下のようになります
code:server/index.tsx
const title = "Server Side Rendering";
// ※1
res.send(renderToString(
// ※2
<Layout title={title}>
// ※3
<div id={"hydration-data"} data-props={JSON.stringify({title, count: clickCount})}></div>
<div id={"root"}>
// ※4
<Index title={title} count={clickCount}/>
</div>
<script src={"/client/hydrate.js"}></script>
</Layout>
)
クライアントのコードは以下のようになります
code:client/hydrate.tsx
const dataDom = document.getElementById("hydration-data");
const props = JSON.parse(dataDom.getAttribute("data-props"));
// ※5
hydrate(
<Index {...props} />, document.getElementById("root")
);
※1 クライアントのルートコンポーネントの上のコンポーネントを用意する
code:Layout.tsx
export const Layout: SFC<{ title }> = ({title, children}) => (
<html>
<head>
<title>{title}</title>
</head>
<body>
{children}
</body>
</html>
);
クライアントのReactのルートコンポーネントの外側となる部分のReactコンポーネントを定義します。
難しいことを言っているようですが、要はHTMLのテンプレートをJSXで記述するだけです。
PHPやslim、pugなどのテンプレートエンジンとやることは同じです。
サーバー/クライアントがJSで書かれているアプリでは、HTMLをJSで記述できるのは大きなメリットになります。
上記のように、簡素なもので大丈夫です。(実際はもっと長大になるでしょうが)
実際はこういったものを用意せずにインラインコンポーネントにべた書きしても問題ありません。
※2 renderToStringを使ってコンポーネントをHTML文字列に変換する
サーバーサイドのReactコンポーネントをクライアントに送る場合、それをHTMLに書き出す必要があります。
renderToStringはreact-dom/serverにある関数で、Reactコンポーネントを受け取ってVDOMをレンダリングし、HTML文字列に変換するものです。
これは使うのはとても簡単です。
他にもrenderToStream, renderToNodeStreamという同じような関数があるのですが、大抵の場合これを使えば問題ないです。
注意する点としてはrenderToStringが非同期関数ではないという点で、CPUヘビーな巨大なコンポーネントを渡すとサーバーが止まってしまいます
※3 ルートコンポーネント用の初期propsを書き込んだDOMを用意する
後述の、クライアントでhydrateするために必要になります。
ルートコンポーネントのpropsは、サーバー側でレンダリングする際にすでにデータとして存在しますが、renderToStringで書き出したHTMLからはpropsは消えてしまっているため、クライアント側でpropsを取得してコンポーネントをレンダリングするために、propsをまるごとJSONにしてDOMに埋め込みます
個人的にはセンシティブなデータなどはここに埋め込むことは避けたいと思っています
とはいえビューを構築するデータだけであればさほど問題にもならないかと思います
※4 クライアントのルートコンポーネント
Indexは、クライアントでレンダリングする際のルートコンポーネントになります
逆に言えば、Indexより上の階層のコンポーネントはクライアントではhydrateされません
SSRにおいては、サーバーとクライアントで管理する層が異なるという点に注意が必要です
※5 ReactDOM.hydrate
順番が前後してしまったのですが、このhydrateという関数がSSRのキモになります。
hydrateは、react-domに存在する関数で、renderの姉妹関数です。 通常、CSRではここはこういった記述になっていました
code:client/render.tsx
render(
<Index {...props} />, document.getElementById("root")
);
CSRではこの時点ではビューは空で、Reactのレンダリングが完了してから実際のDOMが生成されます
一方、hydrateを使用した場合、Reactは指定されたDOM(#root)をルートに、『可能な限り』現存のDOMをもとにVDOMの初期状態を構築します。これをhydrate(水和)と呼ぶそうです。日本語にすると意味わかりませんね。
ここでhydrateする際に、先程サーバー側で書き込んだ初期propsをDOMから読み出しています
code:client/hydrate.tsx
const dataDom = document.getElementById("hydration-data");
const props = JSON.parse(dataDom.getAttribute("data-props"));
個人的あまりスマートな方法ではないような気がするのですが、renderToStringする際にHTMLの方にpropsを埋め込んでくれたりはしないんでしょうかね?
そうしたらこういう記述ができるはずなんですが…
code:client/hydrate.tsx
hydrate(
Index, document.getElementById("root")
)
やることは同じですけども。
SSRの大きな制限
ここまで語ると、SSRは理想的な技術のように聞こえます
hydrateの解説にもある通り、Reactを含むバンドルされたJavaScriptは通常HTMLよりも重く、ブラウザの画面にコンテンツが表示されるまでの時間を短縮できるのであれば、ぜひ使いたい技術です。 しかし、SSRには大きな制限があります
それは、クライアントで動くコンポーネントがサーバーで動くとは限らないという点です
それはどういうことでしょうか?一番簡単な例を上げます。
code:client.tsx
export const ClientOnly = () => {
const w = window.innerWidth
return (
<div style={{width: ${w}px}}>WIDE</div>
)
}
windowから横幅を算出し、動的な幅をもつ要素のコンポーネントです。よく見ますね。
しかしこれをSSRすると何が起こるでしょうか?
code:log
window is not defined
落ちます
Expressなのでプロセスごと落ちることはないですが、とにかくエラーが投げられます
それも当然で、Nodeのランタイムにwindowというグローバルオブジェクトはないからです
SSRでは、コンポーネントのモジュールコンテクスト(ファイルのトップレベル)、コンストラクタ、renderに相当する関数の中身その他で、クライアントを前提としたコードを記述できません
これは大きな、そして無視できない制限になります
なぜならReactを導入する理由としてリッチなクライアントのUI表現があり、そういった複雑なコードを記述する場合はwindowのみならずdocument、location、localStorageなどへのアクセスが必要になってくるからです
こういったクライアント前提のコードを含んだまま、SSRをすることはできないのです
もししたい場合は、すべての登場箇所で複雑なif分岐を書く必要があります
もしくは、強引にwindowなどをNodeのグローバルコンテクストにexportするという荒業もありますが、そもそも存在しないものをミミックするのは無意味で労力に見合いません
加えて、自分のコードを頑張って修正したとしても、サードパーティのライブラリは書き換えられません
チャートなどのリッチなライブラリを使ったコンポーネントは、間違いなくクライアントを前提として作られているので、サーバーでimportした瞬間にエラーになります
これを回避する方法もないことにはないのですが(dynamic impor...)、労力に見合いません。ブラウザ前提のライブラリは星の数ほどあり、それらすべてを修正するのは不可能だからです。
これはSSRに期待を抱いた開発者全員がとおる道でしょう。
更にもう一点無視できない開発上の制限が出てきます。
クライアントとサーバーでコンポーネントを共有するということは、クライアントとサーバーのコードのビルドライフサイクルが密結合するということになります。
コンポーネントの1行の変更は、クライアントのバンドルとサーバーの再起動の両方を引き起こします。
クライアントとサーバーの開発がはっきりと別れている場合、これはクライアント開発者に大きな負担になります。
僕はNode.jsの場合どちらも担当しますが、そうでもないクライアントにしか詳しくな人もいます。
ExpressはWAFの中ではトップクラスに使いやすく簡単なライブラリですが、多くのWebアプリケーションはExpressだけでは動きません。
DB、ピアサービス、そしてDockerコンテナ。様々な付帯技術が必要になります。
ネイティブアプリ並み、もしくはそれ以上に複雑化した現代のWebフロントエンドでそういった複雑な要素をクライアント開発者に強いるのは心象的に厳しいものがあります。
開発ではサーバーはAPIとしてすでにデプロイされていて、クライアントだけローカルで開発するというネイティブスタイルの場所もあるでしょう。
そういう場合において、サーバーとクライアントでビューコンポーネントを共有するというのは、労力の割に実利が少ないのです。
むしろ、クライアントの複雑さが増せば増すほどSSRの旨味はゼロに近づいていくと言っていいでしょう。
SSR、それは夏の夜の夢のような儚いものなのです…
現実的な選択肢としてのハイブリッドレンダリング
悲しい終わり方になりそうですが、サーバーサイドでReactを使うメリットがなにもないというわけではありません。
僕は完全なSSRは無理だと思っていますが、CSRとSSRの中庸な選択肢は存在します。
それがハイブリッドレンダリングです。
そういう名前があるのかどうか知りませんが、とにかく僕はそう呼んでいます。
ハイブリッドレンダリングは、CSRとSSRの良いところをうまく取り出して調和させたものです。
CSRの良い点・悪い点は
良い点: レンダリングがクライアントで完結する。サーバーと密結合しない。
悪い点: ビューの表示が遅れる。SPAでは厳しいアプリもたくさんある。
SSRの良い点・悪い点は
良い点: ビューの表示が速くなる。
悪い点: コンポーネントの共用が非常に大変で現実的でない。
と言ったものでした。
ハイブリッドレンダリングは、サーバーとクライアントでコンポーネントではなくpropsのみを共用するという手法です
サーバーサイドReactの注目すべき点は、ビューをJavaScriptで記述できるという一点につきます
Expressでサーバーを作っている場合、ビューエンジンはいくつかの選択肢がありますが、そのどれよりもJSX(React, hyperappなど)は優れていると言えます
ReactがもたらしたHTML in JSの優秀さは、クライアントとコンポーネントが分離していたとしてもかすまないのです。
ハイブリッドレンダリングは、以下のような特徴を持ちます
クライアントとサーバーでコンポーネントは共用しないが、両方共Reactで記述する
hydrateではなくrenderを使う
クライアントのルートコンポーネントのpropsだけ共有する
コードはこうなります
code:server/index.tsx
const title = "Hybrid Rendering";
res.send(renderToString(
<Layout title={title}>
// ルートコンポーネントを構築するpropsは共用する
<div id={"hydration-data"} data-props={JSON.stringify({title, count: clickCount})}></div>
<div id={"root"}>
// CSRのときと同様にクライアントのルートコンポーネントは使わない
</div>
<script src={"/client/hybrid.js"}></script>
</Layout>
)
クライアントのコードは以下のようになります
code:client/hydrate.tsx
const dataDom = document.getElementById("hydration-data");
const props = JSON.parse(dataDom.getAttribute("data-props"));
// ※5
render(
<Index {...props} />, document.getElementById("root")
);
ちょうど、CSRとSSRのコードの中間になっているかと思います。
サーバーでのクライアントのコンポーネントのレンダリングを諦める代わり、ルートコンポーネントのpropsのデータだけHTMLに書き込んでサーブします
これはどんなメリットがあるのでしょうか?
まずはクライアントでのデータの取得が不必要になります。初期ビューを構築するためのデータをまとめてHTMLに書き込むことで、クライアントではReactのrenderのみを初期化として扱えるようになります
ビューの初期状態をJSON一発で記述できるのはクライアント的には大きなメリットで、Reactのロードとrenderの処理以外にビューを構築する時間が必要になくなります。(これは子コンポーネントの設計によりますが…)
さらに、コンポーネントではなくprops共用するというのは、ReactをTypeScriptで記述していると更に大きなメリットになります。
Reactにおいてはpropsがビューを支配しています。propsがすなわちビューであり、propsが正しければコンポーネントは正しいビューをプレゼントしないといけません
であれば、propsの検証性をクライアントとサーバーで高めれば、必然的にコンポーネントの検証性も担保されることになります。(あくまでステートレスな検証性ですが)
TypeScriptではサーバーで初期propsをDOMにする段階でタイプチェックがなされるので、誤ったデータがクライアントに渡ることが基本的にはなくなります。
つまり、クライアントのコンポーネントは型定義されたpropsを信用できる可能性が高まるということで、これは開発効率向上の一助となります。TypeScriptはいいぞ。
まとめ
というわけでExpressを使ったWebアプリケーションでのReactのレンダリング手法3つを紹介しました
重ねていいますが、どの方法もメリットデメリットがあり、すべてのWebアプリケーションに適しているわけではありません
自分のアプリケーションの用途や開発スタイルにあわせて適した方法を見つけていくべきです
特にSSR/hydrateは用途が相当に限定され、SSRが完璧にできる状態はそもそもReactを使うメリットもあまりない状態です
P.S.
2019年にReactを書くならTypeScriptがいいぞ
TypeScriptのバンドラーならFuseBoxがいいぞ
React Hooks, React Suspenseはやくきてくれー